shared/
storage.rs

1use crate::settings::SettingsReadGuard;
2use compact_str::ToCompactString;
3use serde::{Deserialize, Serialize};
4use std::{path::Path, sync::Arc};
5use tokio::io::AsyncWriteExt;
6use utoipa::ToSchema;
7
8#[derive(ToSchema, Deserialize, Serialize)]
9pub struct StorageAsset {
10    pub name: compact_str::CompactString,
11    pub url: String,
12    pub size: u64,
13    pub created: chrono::DateTime<chrono::Utc>,
14}
15
16fn get_s3_client(
17    access_key: &str,
18    secret_key: &str,
19    bucket: &str,
20    region: &str,
21    endpoint: &str,
22    path_style: bool,
23) -> Result<Box<s3::Bucket>, anyhow::Error> {
24    let mut bucket = s3::Bucket::new(
25        bucket,
26        s3::Region::Custom {
27            region: region.to_string(),
28            endpoint: endpoint.to_string(),
29        },
30        s3::creds::Credentials::new(Some(access_key), Some(secret_key), None, None, None)?,
31    )?;
32
33    if path_style {
34        bucket.set_path_style();
35    }
36
37    Ok(bucket)
38}
39
40pub struct StorageUrlRetriever<'a> {
41    settings: SettingsReadGuard<'a>,
42}
43
44impl<'a> StorageUrlRetriever<'a> {
45    pub fn new(settings: SettingsReadGuard<'a>) -> Self {
46        Self { settings }
47    }
48
49    pub fn get_settings(&self) -> &super::settings::AppSettings {
50        &self.settings
51    }
52
53    pub fn get_url(&self, path: impl AsRef<str>) -> String {
54        match &self.settings.storage_driver {
55            super::settings::StorageDriver::Filesystem { .. } => {
56                format!(
57                    "{}/{}",
58                    self.settings.app.url.trim_end_matches('/'),
59                    path.as_ref()
60                )
61            }
62            super::settings::StorageDriver::S3 { public_url, .. } => {
63                format!("{}/{}", public_url.trim_end_matches('/'), path.as_ref())
64            }
65        }
66    }
67}
68
69pub struct Storage {
70    settings: Arc<super::settings::Settings>,
71}
72
73impl Storage {
74    pub fn new(settings: Arc<super::settings::Settings>) -> Self {
75        Self { settings }
76    }
77
78    pub async fn retrieve_urls(&self) -> Result<StorageUrlRetriever<'_>, anyhow::Error> {
79        let settings = self.settings.get().await?;
80
81        Ok(StorageUrlRetriever::new(settings))
82    }
83
84    pub async fn remove(&self, path: Option<impl AsRef<str>>) -> Result<(), anyhow::Error> {
85        let path = match path {
86            Some(path) => path,
87            None => return Ok(()),
88        };
89        let path = path.as_ref();
90
91        if path.is_empty() || path.contains("..") || path.starts_with("/") {
92            return Err(anyhow::anyhow!("invalid path"));
93        }
94
95        let settings = self.settings.get().await?;
96
97        tracing::debug!(path, "removing file");
98
99        match &settings.storage_driver {
100            super::settings::StorageDriver::Filesystem { path: base_path } => {
101                let base_filesystem =
102                    match crate::cap::CapFilesystem::async_new(base_path.into()).await {
103                        Ok(base_filesystem) => base_filesystem,
104                        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
105                        Err(err) => return Err(err.into()),
106                    };
107                drop(settings);
108
109                if let Err(err) = base_filesystem.async_remove_file(&path).await
110                    && err
111                        .downcast_ref::<std::io::Error>()
112                        .is_none_or(|e| e.kind() != std::io::ErrorKind::NotFound)
113                {
114                    return Err(err);
115                }
116
117                if let Some(parent) = Path::new(path).parent().map(|p| p.to_path_buf()) {
118                    tokio::spawn(async move {
119                        tokio::time::sleep(std::time::Duration::from_secs(10)).await;
120
121                        let mut directory = match base_filesystem.async_read_dir(&parent).await {
122                            Ok(directory) => directory,
123                            Err(_) => return,
124                        };
125
126                        if directory.next_entry().await.is_none() {
127                            base_filesystem.async_remove_dir(parent).await.ok();
128                        }
129                    });
130                }
131            }
132            super::settings::StorageDriver::S3 {
133                access_key,
134                secret_key,
135                bucket,
136                region,
137                endpoint,
138                path_style,
139                ..
140            } => {
141                let s3_client = get_s3_client(
142                    access_key,
143                    secret_key,
144                    bucket,
145                    region,
146                    endpoint,
147                    *path_style,
148                )?;
149                drop(settings);
150
151                s3_client.delete_object(path).await?;
152            }
153        }
154
155        Ok(())
156    }
157
158    pub async fn store(
159        &self,
160        path: impl AsRef<str>,
161        mut data: impl tokio::io::AsyncRead + Unpin,
162        content_type: impl AsRef<str>,
163    ) -> Result<u64, anyhow::Error> {
164        let path = path.as_ref();
165        let content_type = content_type.as_ref();
166
167        if path.is_empty() || path.contains("..") || path.starts_with("/") {
168            return Err(anyhow::anyhow!("invalid path"));
169        }
170
171        let settings = self.settings.get().await?;
172
173        tracing::debug!(path, content_type, "storing file");
174
175        match &settings.storage_driver {
176            super::settings::StorageDriver::Filesystem { path: base_path } => {
177                tokio::fs::create_dir_all(base_path).await?;
178
179                let base_filesystem =
180                    crate::cap::CapFilesystem::async_new(base_path.into()).await?;
181                drop(settings);
182
183                if let Some(parent) = Path::new(path).parent() {
184                    base_filesystem.async_create_dir_all(parent).await?;
185                }
186
187                let mut file = base_filesystem.async_create(path).await?;
188                let bytes = tokio::io::copy(&mut data, &mut file).await?;
189
190                file.shutdown().await?;
191                Ok(bytes)
192            }
193            super::settings::StorageDriver::S3 {
194                access_key,
195                secret_key,
196                bucket,
197                region,
198                endpoint,
199                path_style,
200                ..
201            } => {
202                let s3_client = get_s3_client(
203                    access_key,
204                    secret_key,
205                    bucket,
206                    region,
207                    endpoint,
208                    *path_style,
209                )?;
210                drop(settings);
211
212                let response = s3_client
213                    .put_object_stream_with_content_type(&mut data, path, content_type)
214                    .await?;
215                Ok(response.uploaded_bytes() as u64)
216            }
217        }
218    }
219
220    pub async fn list(
221        &self,
222        path: impl AsRef<str>,
223        page: usize,
224        per_page: usize,
225    ) -> Result<crate::models::Pagination<StorageAsset>, anyhow::Error> {
226        let path = path.as_ref();
227
228        if path.is_empty() || path.contains("..") || path.starts_with("/") {
229            return Err(anyhow::anyhow!("invalid path"));
230        }
231
232        let settings = self.settings.get().await?;
233
234        match &settings.storage_driver {
235            super::settings::StorageDriver::Filesystem { path: base_path } => {
236                let base_filesystem =
237                    match crate::cap::CapFilesystem::async_new(Path::new(base_path).join(path))
238                        .await
239                    {
240                        Ok(base_filesystem) => base_filesystem,
241                        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
242                            return Ok(crate::models::Pagination {
243                                total: 0,
244                                per_page: per_page as i64,
245                                page: page as i64,
246                                data: Vec::new(),
247                            });
248                        }
249                        Err(err) => return Err(err.into()),
250                    };
251                drop(settings);
252
253                let mut directory_reader = base_filesystem.async_walk_dir("").await?;
254                let mut raw_entries = Vec::new();
255
256                while let Some(Ok((is_dir, entry))) = directory_reader.next_entry().await {
257                    if is_dir {
258                        continue;
259                    }
260
261                    raw_entries.push(entry);
262                }
263
264                raw_entries.sort_unstable();
265
266                let total_entries = raw_entries.len();
267                let mut entries = Vec::new();
268                let start = (page - 1) * per_page;
269
270                let storage_url_retriever = self.retrieve_urls().await?;
271
272                for entry in raw_entries.into_iter().skip(start).take(per_page) {
273                    let metadata = match base_filesystem.async_metadata(&entry).await {
274                        Ok(metadata) => metadata,
275                        Err(_) => continue,
276                    };
277
278                    let entry_name = entry.to_string_lossy().to_compact_string();
279
280                    entries.push(StorageAsset {
281                        url: storage_url_retriever.get_url(format!("assets/{entry_name}")),
282                        name: entry_name,
283                        size: metadata.len(),
284                        created: metadata
285                            .created()
286                            .or_else(|_| metadata.modified())?
287                            .into_std()
288                            .into(),
289                    });
290                }
291
292                Ok(crate::models::Pagination {
293                    total: total_entries as i64,
294                    per_page: per_page as i64,
295                    page: page as i64,
296                    data: entries,
297                })
298            }
299            super::settings::StorageDriver::S3 {
300                access_key,
301                secret_key,
302                bucket,
303                region,
304                endpoint,
305                path_style,
306                ..
307            } => {
308                let s3_client = get_s3_client(
309                    access_key,
310                    secret_key,
311                    bucket,
312                    region,
313                    endpoint,
314                    *path_style,
315                )?;
316                drop(settings);
317
318                let buckets = s3_client.list(path.into(), None).await?;
319                let Some(entries) = buckets.into_iter().next().map(|b| b.contents) else {
320                    return Ok(crate::models::Pagination {
321                        total: 0,
322                        per_page: per_page as i64,
323                        page: page as i64,
324                        data: Vec::new(),
325                    });
326                };
327
328                let start = (page - 1) * per_page;
329
330                let storage_url_retriever = self.retrieve_urls().await?;
331
332                Ok(crate::models::Pagination {
333                    total: entries.len() as i64,
334                    per_page: per_page as i64,
335                    page: page as i64,
336                    data: entries
337                        .into_iter()
338                        .skip(start)
339                        .take(per_page)
340                        .map(|e| StorageAsset {
341                            url: storage_url_retriever.get_url(&e.key),
342                            name: e.key.trim_start_matches("assets/").to_compact_string(),
343                            size: e.size,
344                            created: e.last_modified.parse().unwrap_or_default(),
345                        })
346                        .collect(),
347                })
348            }
349        }
350    }
351}