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}