Skip to main content

axum_admin/
storage.rs

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    /// Store `data` (raw bytes) and return the public URL/path to the stored file.
9    /// `original_filename` is used only to preserve the file extension.
10    async fn save(&self, original_filename: &str, data: &[u8]) -> Result<String, AdminError>;
11    /// Delete a stored file by its URL/path (as returned by `save`). Idempotent.
12    async fn delete(&self, url: &str) -> Result<(), AdminError>;
13    /// Convert a stored path to a public URL. For `LocalStorage` this is a no-op
14    /// (save already returns the URL). Custom backends may differ.
15    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}