1use crate::error::AdminError;
2use async_trait::async_trait;
3use std::path::PathBuf;
4use uuid::Uuid;
5
6#[async_trait]
7pub trait FileStorage: Send + Sync {
8 async fn save(&self, original_filename: &str, data: &[u8]) -> Result<String, AdminError>;
11 async fn delete(&self, url: &str) -> Result<(), AdminError>;
13 fn url(&self, path: &str) -> String;
16}
17
18pub struct LocalStorage {
19 root: PathBuf,
20 base_url: String,
21}
22
23impl LocalStorage {
24 pub fn new(root: impl Into<PathBuf>, base_url: impl Into<String>) -> Self {
25 Self {
26 root: root.into(),
27 base_url: base_url.into(),
28 }
29 }
30}
31
32#[async_trait]
33impl FileStorage for LocalStorage {
34 async fn save(&self, original_filename: &str, data: &[u8]) -> Result<String, AdminError> {
35 let ext = std::path::Path::new(original_filename)
36 .extension()
37 .and_then(|e| e.to_str())
38 .unwrap_or("");
39 let uuid_name = if ext.is_empty() {
40 Uuid::new_v4().to_string()
41 } else {
42 format!("{}.{}", Uuid::new_v4(), ext)
43 };
44 let dest = self.root.join(&uuid_name);
45 tokio::fs::write(&dest, data).await.map_err(|e| {
46 AdminError::Custom(format!("Upload failed: {e}"))
47 })?;
48 let base = self.base_url.trim_end_matches('/');
49 Ok(format!("{base}/{uuid_name}"))
50 }
51
52 async fn delete(&self, url: &str) -> Result<(), AdminError> {
53 let base = self.base_url.trim_end_matches('/');
54 if !url.starts_with(base) {
55 return Err(AdminError::Custom("URL does not belong to this storage".to_string()));
56 }
57 let filename = url
58 .trim_start_matches(base)
59 .trim_start_matches('/');
60 let path = self.root.join(filename);
61 if !path.starts_with(&self.root) {
62 return Err(AdminError::Custom("Invalid file path".to_string()));
63 }
64 match tokio::fs::remove_file(&path).await {
65 Ok(_) => Ok(()),
66 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
67 Err(e) => Err(AdminError::Custom(format!("Delete failed: {e}"))),
68 }
69 }
70
71 fn url(&self, path: &str) -> String {
72 path.to_string()
73 }
74}